// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;

namespace IntroSkipper.Manager;

/// <summary>
/// Manages enqueuing library items for analysis.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="QueueManager"/> class.
/// </remarks>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
public partial class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
{
    private readonly ILibraryManager _libraryManager = libraryManager;
    private readonly ILogger<QueueManager> _logger = logger;
    private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
    private double _analysisPercent;
    private List<string> _selectedLibraries = [];
    private bool _selectAllLibraries;
    private bool _analyzeMovies;
    private HashSet<string> _excludeSeries = [];

    /// <summary>
    /// Gets all media items on the server.
    /// </summary>
    /// <returns>Queued media items.</returns>
    public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
    {
        Plugin.Instance!.TotalQueued = 0;

        LoadAnalysisSettings();

        // For all selected libraries, enqueue all contained episodes.
        foreach (var folder in _libraryManager.GetVirtualFolders())
        {
            // If libraries have been selected for analysis, ensure this library was selected.
            if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
            {
                _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
                continue;
            }

            _logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);

            // Some virtual folders don't have a proper item id.
            if (!Guid.TryParse(folder.ItemId, out var folderId))
            {
                continue;
            }

            try
            {
                QueueLibraryContents(folderId);
            }
            catch (Exception ex)
            {
                _logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
            }
        }

        Plugin.Instance.TotalSeasons = _queuedEpisodes.Count;
        Plugin.Instance.QueuedMediaItems.Clear();
        foreach (var kvp in _queuedEpisodes)
        {
            Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value);
        }

        return _queuedEpisodes;
    }

    /// <summary>
    /// Loads the list of libraries which have been selected for analysis and the minimum intro duration.
    /// Settings which have been modified from the defaults are logged.
    /// </summary>
    private void LoadAnalysisSettings()
    {
        var config = Plugin.Instance!.Configuration;

        // Store the analysis percent
        _analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100;

        _selectAllLibraries = config.SelectAllLibraries;
        _analyzeMovies = config.AnalyzeMovies;

        // Split excluded series once, use for both regular and normalized lists
        var excluded = config.ExcludeSeries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        _excludeSeries = [.. excluded.Select(NormalizeSeriesName).Where(s => !string.IsNullOrWhiteSpace(s))];

        if (!_selectAllLibraries)
        {
            // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
            _selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
            // If any libraries have been selected for analysis, log their names.
            _logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries);
        }
        else
        {
            _logger.LogDebug("Not limiting analysis by library name");
        }

        // If analysis settings have been changed from the default, log the modified settings.
        if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
        {
            _logger.LogInformation(
                "Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
                config.AnalysisPercent,
                config.AnalysisLengthLimit,
                config.MinimumIntroDuration);
        }
    }

    private void QueueLibraryContents(Guid id)
    {
        _logger.LogDebug("Constructing anonymous internal query");

        var query = new InternalItemsQuery
        {
            // Order by series name, season, and then episode number so that status updates are logged in order
            ParentId = id,
            OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
            IncludeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie],
            Recursive = true,
            IsVirtualItem = false
        };

        var items = _libraryManager.GetItemList(query, false);

        if (items is null)
        {
            _logger.LogError("Library query result is null");
            return;
        }

        // Queue all episodes on the server for fingerprinting.
        _logger.LogDebug("Iterating through library items");

        foreach (var item in items)
        {
            try
            {
                if (item is Episode episode)
                {
                    QueueEpisode(episode);
                }
                else if (item is Movie movie)
                {
                    if (!_analyzeMovies)
                    {
                        _logger.LogDebug("Skipping movie {Name}: movie analysis is disabled", movie.Name);
                    }
                    else
                    {
                        QueueMovie(movie);
                    }
                }
                else
                {
                    _logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing item {Name} ({Id})", item.Name, item.Id);
            }
        }

        _logger.LogDebug("Queued {Count} episodes", items.Count);
    }

    /// <summary>
    /// Normalizes a series name by removing punctuation and converting to lowercase
    /// to make comparisons more robust.
    /// </summary>
    /// <param name="name">The series name to normalize.</param>
    /// <returns>Normalized series name.</returns>
    private static string NormalizeSeriesName(string name)
    {
        if (string.IsNullOrEmpty(name))
        {
            return string.Empty;
        }

        // Remove all punctuation and convert to lowercase
        return NormalizeSeriesNameRegex().Replace(name, string.Empty)
            .ToLowerInvariant()
            .Trim();
    }

    /// <summary>
    /// Checks if a series is in the excluded list, using normalized name comparison
    /// to handle differences in punctuation.
    /// </summary>
    /// <param name="seriesName">The series name to check.</param>
    /// <returns>True if the series should be excluded, false otherwise.</returns>
    private bool IsSeriesExcluded(string seriesName)
    {
        if (string.IsNullOrEmpty(seriesName))
        {
            return false;
        }

        // Then check normalized match
        var normalizedName = NormalizeSeriesName(seriesName);
        return _excludeSeries.Contains(normalizedName);
    }

    private void QueueEpisode(Episode episode)
    {
        var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");

        if (string.IsNullOrEmpty(episode.Path))
        {
            _logger.LogWarning(
                "Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
                episode.Name,
                episode.SeriesName,
                episode.Id);
            return;
        }

        // Allocate a new list for each new season
        var seasonId = GetSeasonId(episode);
        if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes))
        {
            seasonEpisodes = [];
            _queuedEpisodes[seasonId] = seasonEpisodes;
        }

        if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id))
        {
            _logger.LogDebug(
                "\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
                episode.Name,
                episode.SeriesName,
                episode.Id);
            return;
        }

        var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
        var fingerprintDuration = Math.Min(
            duration >= 5 * 60 ? duration * _analysisPercent : duration,
            60 * pluginInstance.Configuration.AnalysisLengthLimit);

        var maxCreditsDuration = Math.Min(
            duration >= 5 * 60 ? duration * _analysisPercent : duration,
            60 * pluginInstance.Configuration.MaximumCreditsDuration);

        // Queue the episode for analysis
        seasonEpisodes.Add(new QueuedEpisode
        {
            SeriesName = episode.SeriesName,
            SeasonNumber = episode.AiredSeasonNumber ?? 0,
            SeriesId = episode.SeriesId,
            SeasonId = episode.SeasonId,
            EpisodeNumber = episode.IndexNumber ?? 0,
            EpisodeId = episode.Id,
            Name = episode.Name,
            Category = ResolveEpisodeCategory(episode, seasonEpisodes, pluginInstance),
            IsExcluded = IsSeriesExcluded(episode.SeriesName),
            Path = episode.Path,
            Duration = duration,
            IntroFingerprintEnd = fingerprintDuration,
            CreditsFingerprintStart = Math.Max(0, duration - maxCreditsDuration),
        });

        pluginInstance.TotalQueued++;
    }

    private static QueuedMediaCategory ResolveEpisodeCategory(Episode episode, IReadOnlyList<QueuedEpisode> seasonEpisodes, Plugin pluginInstance)
    {
        if (seasonEpisodes.FirstOrDefault()?.Category is QueuedMediaCategory cat && (cat == QueuedMediaCategory.AnimeEpisode || cat == QueuedMediaCategory.Episode))
        {
            return cat;
        }

        if (pluginInstance.GetItem(episode.SeriesId) is Series series &&
            (series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) ||
             series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase)))
        {
            return QueuedMediaCategory.AnimeEpisode;
        }

        return QueuedMediaCategory.Episode;
    }

    private void QueueMovie(Movie movie)
    {
        var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");

        if (string.IsNullOrEmpty(movie.Path))
        {
            _logger.LogWarning(
                "Not queuing movie \"{Name}\" ({Id}) as no path was provided by Jellyfin",
                movie.Name,
                movie.Id);
            return;
        }

        // Allocate a new list for each Movie
        _queuedEpisodes.TryAdd(movie.Id, []);

        var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;

        _queuedEpisodes[movie.Id].Add(new QueuedEpisode
        {
            SeriesName = movie.Name,
            SeriesId = movie.Id,
            SeasonId = movie.Id,
            EpisodeId = movie.Id,
            Name = movie.Name,
            Path = movie.Path,
            Duration = duration,
            CreditsFingerprintStart = Math.Max(0, duration - pluginInstance.Configuration.MaximumMovieCreditsDuration),
            Category = QueuedMediaCategory.Movie,
            IsExcluded = IsSeriesExcluded(movie.Name),
        });

        pluginInstance.TotalQueued++;
    }

    private Guid GetSeasonId(Episode episode)
    {
        if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
        {
            foreach (var kvp in _queuedEpisodes)
            {
                var first = kvp.Value.FirstOrDefault();
                if (first?.SeriesId == episode.SeriesId &&
                    first.SeasonNumber == episode.AiredSeasonNumber)
                {
                    return kvp.Key;
                }
            }
        }

        return episode.SeasonId;
    }

    /// <summary>
    /// Verify that a collection of queued media items still exist in Jellyfin and in storage.
    /// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue.
    /// </summary>
    /// <param name="candidates">Queued media items.</param>
    /// <param name="modes">Analysis modes.</param>
    /// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
    internal IReadOnlyList<QueuedEpisode> VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
    {
        if (candidates == null || candidates.Count == 0)
        {
            return [];
        }

        var verified = new List<QueuedEpisode>(candidates.Count);
        var plugin = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance is null");
        var episodeIds = plugin.GetEpisodeIds(candidates[0].SeasonId);

        foreach (var candidate in candidates)
        {
            try
            {
                var path = plugin.GetItemPath(candidate.EpisodeId);

                if (string.IsNullOrEmpty(path) || !File.Exists(path))
                {
                    _logger.LogDebug("Skipping {Name} ({Id}): file not found", candidate.Name, candidate.EpisodeId);
                    continue;
                }

                verified.Add(candidate);

                var hasSegments = plugin.GetTimestamps(candidate.EpisodeId);

                foreach (var mode in modes)
                {
                    if (episodeIds.TryGetValue(mode, out var ids) && ids.Contains(candidate.EpisodeId))
                    {
                        if (hasSegments.TryGetValue(mode, out _))
                        {
                            candidate.SetAnalyzed(mode, EpisodeState.Analyzed);
                        }
                        else if (!plugin.AnalyzeAgain)
                        {
                            candidate.SetAnalyzed(mode, EpisodeState.NoSegments);
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                _logger.LogDebug(
                    "Skipping analysis of {Name} ({Id}): {Exception}",
                    candidate.Name,
                    candidate.EpisodeId,
                    ex);
            }
        }

        return verified;
    }

    [GeneratedRegex(@"[^\w\s]")]
    private static partial Regex NormalizeSeriesNameRegex();
}
